2.2 结构体与方法集的实现
结构体是我们在实际运用中使用比较多的一个概念,Go
语言封装的比较简单,我们在使用的时候不需要关注太多的东西。
但是如果对于性能有要求、需要开发框架时,我们还是需要对结构体进行一个深入的了解。
本节我们将针对结构体的内存布局、接口实现及面向对象编程等进行讲解。
本节代码存放目录为 lesson2
结构体的内存布局
在上一章中我们讲过了基础类型的内存表示方式,所有定义最终在内存中都会以二进制的形式存储。
在结构体中,其实也是按照这样的方式,只不过是按照每个字段排列的方式进行。
我们以实际的案例进行讲解,结构体代码如下:
type CacheExample struct {
a int8
b int32
c int16
d int16
}
在结构体中,每个字段所占用的位数是按照其类型制定的,如下所示:
type CacheExample struct {
a int8 // 占用8位,1字节
b int32 // 占用32位,4字节
c int16 // 占用16位,2字节
d int16 // 占用16位,2字节
}
从上面的结构体中,我们可以计算出一共占用了9
字节,直观的来看在内存中可以理解为就是这样:
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
其中0
存储了a
字段,1、2、3、4
存储了b
字段,5、6
存储了c
字段,7、8
存储了d
字段。
那么现实中是否是这样的呢?在Go
语言中不是这样的,如果是上面的结构体,在内存中实际是这样的:
| 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 |
存储字段如下所示:
0 ~ 3 地址:存储 a 字段
4 ~ 7 地址:存储 b 字段
8 ~ 9 地址:存储 c 字段
10 ~ 11 地址:存储 d 字段
那么为什么会是这样呢?这是由于 Go
语言中内存对齐的概念。
内存对齐是指:字段存储的位置应该是类型字节数的倍数。
直观的来说,就是比如b
字段int32
是4
字节,那么这个字段存储的起始位置就只能是4
的倍数,比如说:0、4、8、12...
。
也就是说b
在上面的例子中,由于0
已经被a
占用,b
字段只能从4
开始。那么a
与b
之间剩下的空位应该怎么办呢?
这就涉及到了另一个概念,也就是内存填充,也就是说中间空的部分都会填充上默认值。
那么我们在举一个例子:
type CacheExample1 struct {
a int8 // 占用8位,1字节,内存实际占用4字节
b int32 // 占用32位,4字节,内存实际占用4字节
c int8 // 占用8位,1字节,内存实际占用2字节
d int16 // 占用16位,2字节,内存实际占用2字节
}
在上面的例子中,基于内存对齐与内存填充,最终得出的占用字节数就是:12
a字段:int8,占用 1 字节。由于 b 需要 4 字节对齐,所以 a 后面填充了 3 个字节,使得 b 可以从地址 4 开始。因此,a 实际占用了4字节。
b字段:int32,占用 4 字节,实际也占用 4 字节。
c字段:int8,占用 1 字节。为了对齐下一个 d 字段(int16,2 字节对齐),在 c 之后填充了 1 个字节,使得 d 从一个偶数地址开始。
d字段:int16,占用 2 字节,实际占用 2 字节。
我们可以通过代码直接输出结构体所占用的字节数,代码如下所示:
var (
example CacheExample
example1 CacheExample1
)
fmt.Printf("结构体 CacheExample 占用的字节数: %d\n", unsafe.Sizeof(example))
fmt.Printf("结构体 CacheExample1 占用的字节数: %d\n", unsafe.Sizeof(example1))
结果输出如下所示:
结构体 CacheExample 占用的字节数: 12
结构体 CacheExample1 占用的字节数: 12
那么为什么要这样做呢?我们先假设没有补齐,就是一个字段挨着一个字段布局的,如下所示:
地址 | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
---|---|---|---|---|---|---|---|---|
内容 | a | b | b | b | b | ... | ... | ... |
在上面的示意中,由于b
是需要占用4
位的,所以占用了:1、2、3、4
这里我们需要补充一个知识,那就是CPU
在读写内存数据时,都是按照4
位(32
位系统)或者8
位(64
位系统)读写的。
比如CPU
读取的是:0 ~ 3、4 ~ 7
,那么我们现在再回头看,使用连续存储是不是就并不适用了呢?
也就是说,如果我按照连续存储,CPU
首先读取0 ~ 3
拿到第1、2、3
位置的b
字段数据,还要再读取一次4 ~ 7
拿到第4
位置的b
字段数据,之后拼接在一起才会得到实际的b
字段数据。
这种显然是不划算的,所以就出现了补位对齐,那么CPU
在读取的时候直接读取4 ~7
就一次拿到了b
字段的数据。
经过上面的学习,我们现在已经知道了基本的概念,也就是说结构体占用是与字段排序有关系的。我们看下面的例子:
type CacheExample2 struct {
a int8 // 占用8位,1字节,内存实际占用1字节
c int8 // 占用8位,1字节,内存实际占用1字节
d int16 // 占用16位,2字节,内存实际占用2字节
b int32 // 占用32位,4字节,内存实际占用4字节
}
fmt.Printf("结构体 CacheExample2 占用的字节数: %d\n", unsafe.Sizeof(example2))
结果输出如下所示:
结构体 CacheExample2 占用的字节数: 8
看到上面的输出,我们可能会有点奇怪,为什么d
占用不是4
呢?
我们来分析一下,首先a
、c
占用了0、1
位置,接下来的d
字段类型是2
字节,d
直接存储到2、3
。
最后的b
字段类型需要4
字节,而现在刚好排到了4
,所以b
字段直接从4
开始,占用4、5、6、7
。
综上所述,如果需要优化结构体的内存占用,那么我们只需要:将占用小的字段放在前面。
字段访问及方法调用
字段是如何访问的?
在上面我们讲过了结构体的内存布局,也就是一块连续的存储空间,结构体的字段按照定义时候的顺序排列在内存中。
那么在Go
语言中,访问的时候其实也比较简单,就是通过字段偏移量、字段占用位数从内存中取出字段。
比如说:a
字段的存储地址偏移量是 0
,占用字节数是 4
,那么当访问的时候就会从内存的0 ~ 3
去取出。
那么这个偏移量又是基于谁的偏移呢?在Go
语言中,就是相对于结构体内存地址初始位置的偏移。
那么结构体内存初始位置又是如何确定的呢?这是在初始化时,系统会为该结构体变量分配一个内存地址,这个内存地址的初始位置就是结构体的零点。
我们可以通过代码输出字段的偏移量,如下所示:
type MethodExample struct {
Score int16
Age int16
}
fmt.Println("Offset of age:", unsafe.Offsetof(MethodExample{}.Age))
结果输出如下所示:
Offset of age: 2
也就是说Age
字段的偏移量是2
,那么我们核实一下。针对上面的结构体,Score
占用2
字节,那么就是内存中的0、1
,同时Score
也不需要进行补位,所以Age
自然就是从2
开始,偏移量也就是2
。
在上面的示例中,在实际读取Age
字段时,也就是从结构体初始位置读取:2 ~ 3
的数据。
比如结构体初始位置是30
,那么在取Age
字段的时候,就是取内存地址的:32 ~ 33
。
那么Go语言又是怎么知道该访问哪些内存地址的呢
在上面我们讲解了访问计算方法,同时在前面的章节我们讲过机器指令的概念,就是说在编译时会将代码编译为机器能够识别的机器指令。
同样的,对于结构体的访问也是这样的。
在编译的时候,编译器就会将结构体各个字段的偏移量、位数等计算好并且形成机器码,那么在执行的时候就可以直接使用这些机器码进行读取操作。
也就是说其实在编译的时候就会将这些信息计算好形成机器码,并且内嵌到了最终的指令集中。
结构体方法是如何访问的?
结构体方法是我们比较常用的东西,基本上在实际开发中是离不开结构体方法的。
如下代码所示:
type MethodExample struct {
Score int16
Age int16
}
func (m *MethodExample) Print() {
fmt.Printf("MethodExample score is-> %d\n", m.Score)
}
func (m *MethodExample) Set(score int16) {
fmt.Printf("MethodExample set, score-> %s\n", score)
m.Score = score
}
结构体方法与函数有什么区别?
在Go
语言中,类似于上面代码中的方法,它们有一个接收者,也就是:(m * MethodExample)
,这种有接收者的就叫做方法,而普通函数是没有接收者的。
在上面的写法中,我们使用的接收者是指针,这意味着方法接收的是一个指针,也就是引用接收者。同时还有另一种写法,如下所示:
type MethodExample1 struct {
Score int16
Age int16
}
func (m MethodExample1) Print() {
fmt.Printf("MethodExample score is-> %d\n", m.Score)
}
func (m MethodExample1) Set(score int16) {
fmt.Printf("MethodExample set, score-> %d\n", score)
m.Score = score
}
我们可以这样调用:
me := &MethodExample{}
me.Set(12)
me.Print()
var (
me1 MethodExample1
)
me1.Set(12)
me1.Print()
执行代码输出如下:
MethodExample set, score-> 12
MethodExample score is-> 12
MethodExample1 set, score-> 12
MethodExample1 score is-> 0
那么为什么第二个输出的Score
会是0
呢?
这就涉及到了Go
语言方法的底层概念:指针接收者传递的是结构体的指针地址,而值接收者传递的是结构体的一个副本。
也就是说,当我们的接收者为指针时,由于指针指向的是内存地址,所以在调用Set
的时候,操作的就是me
本身。
当我们的接收者为普通值引用时,在调用Set
的时候,其实是复制了一份me1
传递过去,那么这时候操作字段赋值,自然对me1
是不生效的,因为操作的就不是me1
。
所以我们可以进一步的发散。方法是不是本身与函数就是一样的,在调用的时候只不过是把前面的接收者(结构体)作为了一个参数传递过去呢?
答案是肯定的,在底层其实就是这么操作的,本身方法与函数其实是一样的,只不过方法会将接收者作为参数隐式的给传递过去。
结构体方法是怎么被调用到的?
在上面我们讲解过了字段是怎么被访问的,那么我们在调用方法时,也是使用了符号.
就进行了直接调用,那么它的底层又是怎么样的呢?
这里涉及到一个概念,叫做:方法表
。在Go
语言中,会为我们的结构体类型创建一个方法表,当我们在调用时,就会去方法表里面找到我们的方法信息,之后再去调用实际的函数。
结构示意如下所示:
+----------------------+
| MethodExample | <--- 定义的结构体类型
+----------------------+
| Method Table (MT) | <--- 类型T的方法表(与类型关联,不与实例关联)
+----------------------+
| Method 1: |
| - Name: "Print" | --- 方法名称
| - Signature: () | --- 方法签名(参数和返回值类型)
| - Function Ptr: | --- 指向方法实现的函数指针
| |
| Method 2: |
| - Name: "Set" |
| - Signature: (int16) |
| - Function Ptr: |
| |
| ... |
+----------------------+
在实际的应用中,当我们创建了一个结构体后,就会形成一个方法表,这个方法表是与类型关联的,比如与MethodExample
这个类型关联。
在这个方法表中,标识了对应的结构体类型、方法的列表,在方法信息中包含了方法名称、方法签名以及指向实际函数的指针。
总之可以大概理解为,当我们定义结构体类型时,这个类型就会附带上一个表,这个表里面存储了这个结构体所有的方法信息。
需要注意是的,这个方法表是与类型本身MethodExample
关联的,而不是与创建的实例me
关联的。
在上面我们讲到了结构体类型与方法表的关系及结构,那么在我们实际使用的时候,又是怎么去调用到的呢?
我们可以通过下面的结构来探索:
+----------------------+
| me of MethodExample | <--- 结构体实例 me
+----------------------+
| score: int16 | <--- 结构体字段 score
| age: int6 | <--- 结构体字段 age
| ... |
+----------------------+
调用 `me.Print()` 时:
1. Go 运行时系统知道 me 是 MethodExample 类型。
2. Go 通过 MethodExample 的类型信息找到 MethodExample 的方法表(MT)。
3. 在方法表中找到 Print 的函数指针。
4. 使用该函数指针调用 Print 的实现。
从上面的示意我们可以看出,在执行调用时,运行时系统会通过me
找到他的实际类型,也就是MethodExample
,之后再次通过类型找到了所关联的方法表MT
,下一步就是使用指针直接去调用实际的 Print
函数。
需要注意的是:方法表与前面所讲到的一样,都是由编译器在编译时就已经生成的了,在使用的时候直接进行查找即可。
小结
本节我们讲解了结构体的内存布局、字段访问、方法与函数的区别以及方法调用。
关于本节总结如下:
结构体字段按照定义的顺序排列存储
字段存储的位置应该是类型字节数的倍数
字段内存排列时需要补位对齐
将占用位数小的字段放在前面可以减少内存占用
结构体在初始化时得到了初始内存地址
通过字段偏移量、字节数访问内存地址得到字段
结构体方法本质上与函数是一样的
结构体类型关联一个方法表,方法表记录了结构体实现的所有方法信息
调用时通过类型关联查找得到最终的函数进行调用